iT邦幫忙

2024 iThome 鐵人賽

DAY 25
0
自我挑戰組

30 天 vueuse 原始碼閱讀與實作系列 第 25

[Day 25] useElementVisibility - unit test

  • 分享至 

  • xImage
  •  

今天來看 useElementVisibility 的單元測試,測試中會用到 Day24 看到的 useElementVisibility 以及 Day23 的 useIntersectionObserver,可以參照著看,了解主要用法與細節。以下如果有跟上述 API 有關的程式,就稍微帶過就好~

unit test

測試結構:

// src/compositions/useElementVisibility.test.js

import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { useElementVisibility } from '@/compositions/useElementVisibility'

describe('useElementVisibility', () => {
  let el

  beforeEach(() => {
    el = document.createElement('div')
  })
  
  // 測試案例都會放在這個層級
})

在跑每個測試案例之前,會透過 beforeEach 把 el 設定成 div 元素。以下講的案例都會放在註解的層級。

useElementVisibility 邊界條件測試

參數 target 為 null

// src/compositions/useElementVisibility.test.js

it('should work when el is not an element', async () => {
    const visible = useElementVisibility(null)
    expect(visible.value).toBeFalsy()
})

useElementVisibility 把 null 傳給 useIntersectionObserver,useIntersectionObserver 對 taret 參數做的處理:

// src/compositions/useIntersectionObserver.js
const targets = computed(() => {
    const _target = toValue(target)
    return (Array.isArray(_target) ? _target : [_target]).map(unrefElement).filter(notNullish)
})

// 在核心邏輯中有 if (!targets.length) return 的判斷,所以不會進到計算

filter(notNullish) 會把 null 值 filter 掉。

參數 window 為 null

// src/compositions/useElementVisibility.test.js
it('should work when window is undefined', () => {
    const visible = useElementVisibility(el, { window: null })
    expect(visible.value).toBeFalsy()
})

useElementVisibility 把 { window: null } 傳給 useIntersectionObserver,useIntersectionObserver 對 window 參數做的處理:

// src/compositions/useElementVisibility.test.js

const isSupported = useSupported(() => window && 'IntersectionObserver' in window)
// isSupported.value 為 false 的話,不會成功建立 watch

參數 threshold 為 null

// src/compositions/useElementVisibility.test.js

it('should work when threshold is undefined', () => {
    const visible = useElementVisibility(el, { threshold: null })
    expect(visible.value).toBeFalsy()
})

threshold 就沒有做什麼處理,會一路往下傳,傳給 IntersectionObserver 這個 Web API,看起來效果跟沒傳一樣。

驗證 useElementVisibility 內部使用到的 useIntersectionObserver

接下來的案例都會做 useIntersectionObserver 的相關驗證,一樣會放在測試結構中註解的層級,不過有多包一層 describe。先來看一下結構:

// src/compositions/useElementVisibility.test.js

// ...略
describe('when internally using useIntersectionObserver', async () => {
    beforeAll(() => {
      vi.resetAllMocks()
      vi.mock('@/compositions/useIntersectionObserver')
    })

    const { useIntersectionObserver } = await import('@/compositions/useIntersectionObserver')

    // 接下來的測試案例都會放在這個層級
})

這邊使用 beforeAll 在這個 describe 裡面的所以測試執行前,先 resetAllMocks 並且對 useIntersectionObserver 這個模組進行 mock。接下來對 useIntersectionObserver 用了動態載入 ,看起來是想確定等模組被 mock 完後才拿被 mock 過的 useIntersectionObserver 來測試。

useElementVisibility 執行,useIntersectionObserver 也要被呼叫

// src/compositions/useElementVisibility.test.js

it('should call useIntersectionObserver internally', () => {
  expect(useIntersectionObserver).toHaveBeenCalledTimes(0)
  useElementVisibility(el)
  expect(useIntersectionObserver).toHaveBeenCalledTimes(1)
})

這個案例滿單純的,測試 useElementVisibility 執行後,內部的 useIntersectionObserver 是否有被執行。

useElementVisibility 執行,useIntersectionObserver 被呼叫時是否帶上正確參數

// src/compositions/useElementVisibility.test.js

it('passes the given element to useIntersectionObserver', () => {
  useElementVisibility(el)

  // for TS
  // expect(vi.mocked(useIntersectionObserver).mock.lastCall?.[0]).toBe(el)

  // for JS
  expect(useIntersectionObserver.mock.lastCall?.[0]).toBe(el)
})

這邊原始碼是用 TS 版本的 vi.mocked 來做驗證,是 TS 型別 helper,可以觀察使用 vi.mocked 後,IDE 的提示變得很完整。

測試的部分是透過 lastCall[0] 來判斷最後一次呼叫時帶入的參數是否為 el

當 useIntersectionObserver 執行 callback 的時候,isIntersecting 為 true,isVisible 才能為 true,否則 isVisible 都要是 false

// src/compositions/useElementVisibility.test.js

it('passes a callback to useIntersectionObserver that sets visibility to false only when isIntersecting is false', () => {
      const isVisible = useElementVisibility(el)
      const callback = useIntersectionObserver.mock.lastCall?.[1]
      const callMockCallbackWithIsIntersectingValue = isIntersecting => callback?.([{ isIntersecting, time: 1 }], {})

      // It should be false initially
      expect(isVisible.value).toBe(false)

      // It should still be false if the callback doesn't get an isIntersecting = true
      callMockCallbackWithIsIntersectingValue(false)
      expect(isVisible.value).toBe(false)

      // But it should become true if the callback gets an isIntersecting = true
      callMockCallbackWithIsIntersectingValue(true)
      expect(isVisible.value).toBe(true)

      // And it should become false again if isIntersecting = false
      callMockCallbackWithIsIntersectingValue(false)
      expect(isVisible.value).toBe(false)
})

這段有點複雜,這邊透過 mock.lastCall?.[1] 取到我們傳給 useIntersectionObserver 的第二個參數,也就是 callback,這個 callback Day24 有提到:

// src/compositions/useElementVisibility.js

(intersectionObserverEntries) => {
      let isIntersecting = elementIsVisible.value

      // Get the latest value of isIntersecting based on the entry time
      let latestTime = 0
      for (const entry of intersectionObserverEntries) {
        if (entry.time >= latestTime) {
          latestTime = entry.time
          isIntersecting = entry.isIntersecting
        }
      }
      elementIsVisible.value = isIntersecting
},

接著看測試案例中的 const callMockCallbackWithIsIntersectingValue = isIntersecting => callback?.([{ isIntersecting, time: 1 }], {}) 這段。

現在我們可以透過 callMockCallbackWithIsIntersectingValue(true) 來控制 intersectionObserverEntries 這個參數應該要是什麼樣子,這樣應該也比較清楚為什麽需要 time: 1 了,因為我們有 if (entry.time >= latestTime) 這個判斷,詳情可以再參考昨天的文章。
可以控制 callback 的 intersectionObserverEntries 參數後,就可以測試這個測試 isVisible 是否有正確切換。

在 Intersection Observer API 給 callback 的 entries 有多筆時,應該使用最新的那筆來判斷

// src/compositions/useElementVisibility.test.js

it('uses the latest version of isIntersecting when multiple intersection entries are given', () => {
      const isVisible = useElementVisibility(el)
      const callback = vi.mocked(useIntersectionObserver).mock.lastCall?.[1]
      const callMockCallbackWithIsIntersectingValues = (...entries) => {
        callback?.(entries, {})
      }

      // It should be false initially
      expect(isVisible.value).toBe(false)

      // It should take the latest value of isIntersecting
      callMockCallbackWithIsIntersectingValues(
        { isIntersecting: false, time: 1 },
        { isIntersecting: false, time: 2 },
        { isIntersecting: true, time: 3 },
      )
      expect(isVisible.value).toBe(true)

      // It should take the latest even when entries are out of order
      callMockCallbackWithIsIntersectingValues(
        { isIntersecting: true, time: 1 },
        { isIntersecting: false, time: 3 },
        { isIntersecting: true, time: 2 },
      )

      expect(isVisible.value).toBe(false)
})

跟上面那個案例很像,一樣是透過 callMockCallbackWithIsIntersectingValues 來控制參數來達成我們測試目的。Day24 最後有花一些篇幅來討論為什麽原始碼實作要用 entry.time 來判斷,而不是直接拿第 0 筆來使用,因為 Intersection Observer API 的特性,要拿到最新的 entry 需要透過 entry.time 來判斷。細節可以在參考昨天那篇。

所以驗證 isVisible 最後的值都是 time 最大的那筆物件中的 isIntersecting 對應到的值,因為那筆才是最新狀態。

測試傳給 useIntersectionObserver 的第三個參數

it('passes the given window to useIntersectionObserver', () => {
      const mockWindow = {}

      useElementVisibility(el, { window: mockWindow })
      expect(vi.mocked(useIntersectionObserver).mock.lastCall?.[2]?.window).toBe(mockWindow)
})

it('uses the given scrollTarget as the root element in useIntersectionObserver', () => {
      const mockScrollTarget = document.createElement('div')

      useElementVisibility(el, { scrollTarget: mockScrollTarget })
      expect(vi.mocked(useIntersectionObserver).mock.lastCall?.[2]?.root).toBe(mockScrollTarget)
})

這兩個測試很像,針對第三個 options 參數做測試,測試傳入的參數跟實際上被呼叫的是否相同。

GitHub PR:https://github.com/RhinoLee/30days_vue/pull/24/files


useElementVisibility 的單元測試到這邊告一段落,大部分案例都還滿單純,就後來那兩個控制 callback 的案例比較複雜一點,有時候會被 callback 弄到迷路 XD 而且這種測試方式要我自己想出來可能也沒辦法,又上了一課的感覺。

這幾天接連看了 useScroll API、useIntersectionObserver API、useElementVisibility API,都是為了接下來的 useInfiniteScroll API,明天就從這支 API 繼續~


上一篇
[Day 24] useElementVisibility
下一篇
[Day 26] useInfiniteScroll
系列文
30 天 vueuse 原始碼閱讀與實作30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言